!jupyter nbconvert --to html ML_LoanStatus.ipynb
[NbConvertApp] Converting notebook ML_LoanStatus.ipynb to html [NbConvertApp] Writing 4439509 bytes to ML_LoanStatus.html
import warnings
warnings.filterwarnings('ignore')
import pandas as pd
pd.set_option('display.max_columns', None)
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.graph_objects as go
from datetime import date
#funzione per rimuovere gli outlier
def canc_out (nome_col):
global df
limite_sup = df[nome_col].quantile(0.999) #calcoliamo limite superiore
limite_inf = df[nome_col].quantile(0.001) #calcoliamo limite inferiore
#registriamo nel df solo i valori entro i limiti
df = df[(df[nome_col]>=limite_inf) & (df[nome_col]<=limite_sup)]
df = pd.read_csv('SBAnational.csv')
Questo dataset contiene circa 900.000 prestiti garantiti (anche solo in parte) dalla SBA.
La SBA (Small Business Administration) è un'agenzia governativa che offre supporto alle PMI statunitensi, offrendo prestiti attraverso banche locali, garantiti fino ad un massimo del 90%. Questo consente alle piccole imprese di accedere a risorse offerte da banche locali, con quest'ultime che vedono diminuito il rischio di credito grazie alle garanzie della SBA.
Le colonne raccolgono le seguenti informazioni:
LoanNr_ChkDgt - L'identificativo di ciascun prestito
Name - Il nome del debitore
City - La città del debitore
State - Lo stato di residenza del debitore
Zip - Lo ZipCode del debitore
Bank - Nome della banca
BankState - Lo stato in cui ha sede la banca
NAICS - Sistema di classificazione delle aziende americane (Le prime due cifre indicano il settore)
ApprovalDate - Data di approvazione da parte del SBA
ApprovalFY - Anno fiscale di approvazione
Term - Duration in mesi
NoEmp - Numero di dipendenti dell'azienda richiedente
NewExist - Indica se l'azienda è già esistente oppure se è nel suo primo anno di vita (1 = esistente,2 = nuova)
CreateJob - Posti di lavoro creati dall'azienda
RetainedJob - Posti di lavoro mantenuti
FranchiseCode - Codice Franchise (00000 o 00001 = No Franchise)
UrbanRural - Indica dove è localizzata l'impresa (1 = Città, 2 = Provincia, 0 = Indefinito)
RevLineCR - Indica una linea di credito 'revolving' (Y = Si, N = No)
LowDoc - Indica un prestito 'LowDoc' (Prestito di entità minore, Y = Si, N = No)
ChgOffDate - Data in cui un prestito è diventato inesigibile
DisbursementDate - Data rimborso del prestito
DisbursementGross - Ammontare del rimborso
BalanceGross - Importo residuo
MIS_Status - Stato del prestito (CHGOFF = inesigibile, PIF = Rimborsato)
ChgOffPrinGr - Ammontare non riscosso
GrAppv - Ammontare del prestito accordato dalla banca
SBA_Appv - Ammontare garantito da SBA
df.head(4)
| LoanNr_ChkDgt | Name | City | State | Zip | Bank | BankState | NAICS | ApprovalDate | ApprovalFY | Term | NoEmp | NewExist | CreateJob | RetainedJob | FranchiseCode | UrbanRural | RevLineCr | LowDoc | ChgOffDate | DisbursementDate | DisbursementGross | BalanceGross | MIS_Status | ChgOffPrinGr | GrAppv | SBA_Appv | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1000014003 | ABC HOBBYCRAFT | EVANSVILLE | IN | 47711 | FIFTH THIRD BANK | OH | 451120 | 28-Feb-97 | 1997 | 84 | 4 | 2.0 | 0 | 0 | 1 | 0 | N | Y | NaN | 28-Feb-99 | $60,000.00 | $0.00 | P I F | $0.00 | $60,000.00 | $48,000.00 |
| 1 | 1000024006 | LANDMARK BAR & GRILLE (THE) | NEW PARIS | IN | 46526 | 1ST SOURCE BANK | IN | 722410 | 28-Feb-97 | 1997 | 60 | 2 | 2.0 | 0 | 0 | 1 | 0 | N | Y | NaN | 31-May-97 | $40,000.00 | $0.00 | P I F | $0.00 | $40,000.00 | $32,000.00 |
| 2 | 1000034009 | WHITLOCK DDS, TODD M. | BLOOMINGTON | IN | 47401 | GRANT COUNTY STATE BANK | IN | 621210 | 28-Feb-97 | 1997 | 180 | 7 | 1.0 | 0 | 0 | 1 | 0 | N | N | NaN | 31-Dec-97 | $287,000.00 | $0.00 | P I F | $0.00 | $287,000.00 | $215,250.00 |
| 3 | 1000044001 | BIG BUCKS PAWN & JEWELRY, LLC | BROKEN ARROW | OK | 74012 | 1ST NATL BK & TR CO OF BROKEN | OK | 0 | 28-Feb-97 | 1997 | 60 | 2 | 1.0 | 0 | 0 | 1 | 0 | N | Y | NaN | 30-Jun-97 | $35,000.00 | $0.00 | P I F | $0.00 | $35,000.00 | $28,000.00 |
#Totale valori null per colonna
df.isnull().sum()
LoanNr_ChkDgt 0 Name 14 City 30 State 14 Zip 0 Bank 1559 BankState 1566 NAICS 0 ApprovalDate 0 ApprovalFY 0 Term 0 NoEmp 0 NewExist 136 CreateJob 0 RetainedJob 0 FranchiseCode 0 UrbanRural 0 RevLineCr 4528 LowDoc 2582 ChgOffDate 736465 DisbursementDate 2368 DisbursementGross 0 BalanceGross 0 MIS_Status 1997 ChgOffPrinGr 0 GrAppv 0 SBA_Appv 0 dtype: int64
Le colonne: 'Name, 'Bank', 'BankState', 'ChgOffDate' presentano un alto numero di valori null. Non sono utili alla nostra indagine quindi possono essere tranquillamente eliminate dal df.
df.drop('Bank', axis=1, inplace=True)
df.drop('BankState' , axis=1, inplace=True)
df.drop('ChgOffDate' , axis=1, inplace=True)
df.drop('Name', axis=1, inplace=True)
#controlliamo la presenza di righe duplicate
print(f' Il Dataset presenta {len(df["LoanNr_ChkDgt"]) - len(df["LoanNr_ChkDgt"].unique())} id duplicati')
Il Dataset presenta 0 id duplicati
La feature NAICS indica, nelle prime due cifre, il settore in cui opera l'azienda.
Quindi le recuperiamo, eliminando i valori NULL
Rinominiamo poi la feature in un più comprensibile 'IndustryType', sostituendo i codici con il nome del settore
df = df[df.NAICS != 0]
df['NAICS'] = df['NAICS'].astype(str)
df['NAICS'] = df['NAICS'].str[:2]
df.rename(columns={'NAICS':'IndustryType'}, inplace=True)
df['IndustryType'] = df['IndustryType'].map({
'11': 'Ag/For/Fish/Hunt',
'21': 'Min/Quar/Oil_Gas_ext',
'22': 'Utilities',
'23': 'Construction',
'31': 'Manufacturing',
'32': 'Manufacturing',
'33': 'Manufacturing',
'42': 'Wholesale_trade',
'44': 'Retail_trade',
'45': 'Retail_trade',
'48': 'Trans/Ware',
'49': 'Trans/Ware',
'51': 'Information',
'52': 'Finance/Insurance',
'53': 'RE/Rental/Lease',
'54': 'Prof/Science/Tech',
'55': 'Mgmt_comp',
'56': 'Admin_sup/Waste_Mgmt_Rem',
'61': 'Educational',
'62': 'Healthcare/Social_assist',
'71': 'Arts/Entertain/Rec',
'72': 'Accom/Food_serv',
'81': 'Other_no_pub',
'92': 'Public_Admin'})
#Controlliamo la feature UrbanRural
df['UrbanRural'].unique()
array([0, 1, 2], dtype=int64)
#La feature presenta errori di data entry, eliminiamoli
df[(df['UrbanRural'] == 1) & (df['UrbanRural'] == 2)]
df['UrbanRural'] = np.where(df['UrbanRural'] == 1, 1, 0)
df.rename(columns={"UrbanRural":"IsUrban"},inplace=True)
La feature MIS_Status comprende due diversi valori:
PIF = Paid in Full, CHGOFF = Loan Status Charged off
Per comodità rinominiamo la colonna 'MIS_Status' in 'Default' e convertiamo i valori con una logica binaria: 0 per i prestiti PIF e 1 per i prestiti CHGOFF
#rinominiamo la Feature e eliminiamo valori null
df.rename(columns={'MIS_Status' :'Default'}, inplace=True)
df.dropna(subset=['Default'], inplace=True)
df['Default'].replace(['P I F'], 0, inplace=True)
df['Default'].replace(['CHGOFF'], 1, inplace=True)
df['Default'] = df['Default'].astype(int)
#controlliamo se esistono altri valori null
df.isnull().sum()
LoanNr_ChkDgt 0 City 0 State 8 Zip 0 IndustryType 0 ApprovalDate 0 ApprovalFY 0 Term 0 NoEmp 0 NewExist 134 CreateJob 0 RetainedJob 0 FranchiseCode 0 IsUrban 0 RevLineCr 2335 LowDoc 2578 DisbursementDate 1877 DisbursementGross 0 BalanceGross 0 Default 0 ChgOffPrinGr 0 GrAppv 0 SBA_Appv 0 dtype: int64
#eliminiamo i valori null rimasti
df.dropna(inplace=True)
#Controlliamo la formattazione
df.dtypes
LoanNr_ChkDgt int64 City object State object Zip int64 IndustryType object ApprovalDate object ApprovalFY object Term int64 NoEmp int64 NewExist float64 CreateJob int64 RetainedJob int64 FranchiseCode int64 IsUrban int32 RevLineCr object LowDoc object DisbursementDate object DisbursementGross object BalanceGross object Default int32 ChgOffPrinGr object GrAppv object SBA_Appv object dtype: object
ApprovalFY, DisbursementGross, BalanceGross, ChgOffPrinGr, GrAppv, SBA_Appv sono una serie di colonne di tipo 'object' nonostante dovrebbero contenere dei numeri
#convertiamo la colonna 'ApprovalFY' in integer
df['ApprovalFY'] = df['ApprovalFY'].replace({'A':'','B':''}).astype(int)
#analizziamo le altre 5 colonne
df[['DisbursementGross', 'BalanceGross', 'ChgOffPrinGr', 'GrAppv', 'SBA_Appv']].head()
| DisbursementGross | BalanceGross | ChgOffPrinGr | GrAppv | SBA_Appv | |
|---|---|---|---|---|---|
| 0 | $60,000.00 | $0.00 | $0.00 | $60,000.00 | $48,000.00 |
| 1 | $40,000.00 | $0.00 | $0.00 | $40,000.00 | $32,000.00 |
| 2 | $287,000.00 | $0.00 | $0.00 | $287,000.00 | $215,250.00 |
| 5 | $517,000.00 | $0.00 | $0.00 | $517,000.00 | $387,750.00 |
| 7 | $45,000.00 | $0.00 | $0.00 | $45,000.00 | $36,000.00 |
Queste ultime 5 colonne non possono essere convertite in float finchè conterranno caratteri come '$' o le virgole come separatori. Scriviamo una funzione per pulire i dati e convertirli in float.
#funzione per pulire i dati
def clean_num (number):
number = number.replace("$", "")
number = number.replace(",","")
number = number.replace(" ","")
return float(number)
#applichiamo la funzione
df['DisbursementGross'] = df['DisbursementGross'].apply(lambda x: clean_num(x))
df['BalanceGross'] = df['BalanceGross'].apply(lambda x: clean_num(x))
df['ChgOffPrinGr'] = df['ChgOffPrinGr'].apply(lambda x: clean_num(x))
df['GrAppv'] = df['GrAppv'].apply(lambda x: clean_num(x))
df['SBA_Appv'] = df['SBA_Appv'].apply(lambda x: clean_num(x))
#Convertiamo le colonne 'NewExist', 'FranchiseCode' in modo che seguano una logica binaria (0/1)
df['NewExist']=df['NewExist'].replace(1,0).astype(int)
df['NewExist']=df['NewExist'].replace(2,1).astype(int)
df.rename(columns={"NewExist":"IsNew"},inplace=True)
df['FranchiseCode'] = df['FranchiseCode'].replace(1,0)
df['FranchiseCode'] = np.where((df.FranchiseCode != 0),1,df.FranchiseCode)
df.rename(columns={"FranchiseCode":"IsFranchise"},inplace=True)
Non riusciamo a formattare 'RevLineCr' e 'LowDoc' come int. Proviamo ad indagare
df['RevLineCr'].unique()
array(['N', 'Y', '0', 'T', '`', ',', '1', 'C', '2', 'R', '7', 'A', '-',
'Q'], dtype=object)
df['LowDoc'].unique()
array(['Y', 'N', 'C', '1', 'S', 'R', 'A', '0'], dtype=object)
Queste due colonne presentano diversi errori di Data Entry. Dobbiamo eliminarli.
#Pulizia colonna RevLineCr
df[(df['RevLineCr'] == 'Y') & (df['RevLineCr'] == 'N')]
df['RevLineCr'] = np.where(df['RevLineCr'] == 'N', 0, 1)
#Pulizia colonna LowDoc
df[(df['LowDoc'] == 'Y') & (df['LowDoc'] == 'N')]
df['LowDoc'] = np.where(df['LowDoc'] == 'N', 0, 1)
'DisbursementDate' dovrebbe essere di tipo datetime, inoltre ci interessa soltanto l'anno.
df['DisbursementDate'] = df['DisbursementDate'].astype(str)
df['DisbursementDate'] = pd.to_datetime(df['DisbursementDate'])
df['DisbursementDate'] = df['DisbursementDate'].map(lambda x: x.year)
df.rename(columns={"DisbursementDate":"DisbursementFY"},inplace=True)
Voglio aggiungere due features al dataframe:
Una chiamata 'Recession'. Sarà una variabile binaria che indicherà se nel lasso di tempo in cui il prestito andava rimborsato, la nazione si trovasse negli anni della recessione 2007-2009.
Un'altra chiamata 'Real Estate' in cui segnalare se un prestito è garantito da valori immobiliari. Solitamente questo è il caso di prestiti a lungo termine (>20 anni) e hanno una probabilità di default minore, ragione per il quale è ragionevole considerarla una feature utile.
# Prestiti garantiti da valori immobiliari
df['RealEstate'] = np.where(df['Term'] >= 240, 1, 0)
# Prestiti attivi durante la Recessione del 2007-2009
df['Recession'] = np.where(((2007 <= df['DisbursementFY']) & (df['DisbursementFY'] <= 2009)) |
((df['DisbursementFY'] < 2007) & (df['DisbursementFY'] + (df['Term']/12) >= 2007)), 1, 0)
Nel dataset viene indicato l'ammontare del prestito garantito da SBA, ma sarebbe più utile rappresentarlo come percentuale rispetto al totale.
df['SBA_Pct'] = (df['SBA_Appv'] / df['GrAppv']) * 100
Adesso possiamo completare la fase di analisi e pulizia eliminando le colonne che non ci serviranno nelle fasi successive:
LoanNr_ChkDgt - Non abbiamo interesse nello studiare l'ID
City e Zip - A livello geografico siamo interessati solo alla feature State
ApprovalDate - si ritiene sia più utile la feature ApprovalFY
SBA_Appv - si ritiene sia più utile fare riferimento alla percentuale garantita da SBA
df.drop(columns=['LoanNr_ChkDgt', 'City', 'Zip', 'ApprovalDate', 'BalanceGross', 'ChgOffPrinGr', 'SBA_Appv'], inplace=True)
df.isnull().sum()
State 0 IndustryType 0 ApprovalFY 0 Term 0 NoEmp 0 IsNew 0 CreateJob 0 RetainedJob 0 IsFranchise 0 IsUrban 0 RevLineCr 0 LowDoc 0 DisbursementFY 0 DisbursementGross 0 Default 0 GrAppv 0 RealEstate 0 Recession 0 SBA_Pct 0 dtype: int64
df.shape
(688603, 19)
Dopo la prima fase di pulizia nel nostro dataframe possiamo contare su 688.603 entries per 19 features
Ora che il df è pulito possiamo effettuare un'analisi superficiale
df.describe(include=['object', 'float', 'int32', 'int64'])
| State | IndustryType | ApprovalFY | Term | NoEmp | IsNew | CreateJob | RetainedJob | IsFranchise | IsUrban | RevLineCr | LowDoc | DisbursementFY | DisbursementGross | Default | GrAppv | RealEstate | Recession | SBA_Pct | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| count | 688603 | 688603 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 688603.000000 | 6.886030e+05 | 688603.000000 | 6.886030e+05 | 688603.000000 | 688603.000000 | 688603.000000 |
| unique | 51 | 20 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| top | CA | Retail_trade | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| freq | 101089 | 125897 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| mean | NaN | NaN | 2003.001801 | 102.975848 | 9.869254 | 0.277035 | 2.170978 | 5.121073 | 0.050332 | 0.647685 | 0.611002 | 0.099586 | 2003.022826 | 1.947877e+05 | 0.202638 | 1.830695e+05 | 0.141822 | 0.746105 | 67.948042 |
| std | NaN | NaN | 4.933211 | 74.778997 | 61.523842 | 0.447534 | 14.944693 | 20.718495 | 0.218630 | 0.477692 | 0.487523 | 0.299447 | 4.872445 | 2.964543e+05 | 0.401965 | 2.899559e+05 | 0.348868 | 0.435239 | 17.534612 |
| min | NaN | NaN | 1984.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 1984.000000 | 4.000000e+03 | 0.000000 | 1.000000e+03 | 0.000000 | 0.000000 | 2.813996 |
| 25% | NaN | NaN | 2001.000000 | 60.000000 | 2.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 2001.000000 | 3.665700e+04 | 0.000000 | 3.000000e+04 | 0.000000 | 0.000000 | 50.000000 |
| 50% | NaN | NaN | 2004.000000 | 84.000000 | 4.000000 | 0.000000 | 0.000000 | 1.000000 | 0.000000 | 1.000000 | 1.000000 | 0.000000 | 2004.000000 | 9.000000e+04 | 0.000000 | 7.500000e+04 | 0.000000 | 1.000000 | 75.000000 |
| 75% | NaN | NaN | 2006.000000 | 120.000000 | 9.000000 | 1.000000 | 2.000000 | 5.000000 | 0.000000 | 1.000000 | 1.000000 | 0.000000 | 2006.000000 | 2.175000e+05 | 0.000000 | 2.000000e+05 | 0.000000 | 1.000000 | 85.000000 |
| max | NaN | NaN | 2014.000000 | 569.000000 | 9999.000000 | 1.000000 | 5621.000000 | 9500.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 2028.000000 | 1.144632e+07 | 1.000000 | 5.000000e+06 | 1.000000 | 1.000000 | 100.000000 |
Emerge che:
Ripuliamo le feautures che sembrano contenere degli outliers
colonne = ['Term', 'NoEmp', 'CreateJob', 'RetainedJob',]
#chiamiamo la funzione
for col in colonne:
canc_out(col)
Raccogliamo delle informazioni aggiuntive:
print('PRIMI 5 STATI PER PRESTITI')
print(df['State'].value_counts().head(5).to_string())
print('------------------------')
print('PRIMI 5 SETTORI PER PRESTITI')
print(df['IndustryType'].value_counts().head(5).to_string())
print('------------------------')
print('NUMERO DI NUOVE IMPRESE (1 = Nuova impresa)')
print(df['IsNew'].value_counts().to_string())
print('------------------------')
print('NUMERO DI IMPRESE FRANCHISE (1 = Franchise)')
print(df['IsFranchise'].value_counts().to_string())
print('------------------------')
print('NUMERO DI IMPRESE CON SEDE IN UN CENTRO URBANO (1 = Centro urbano)')
print(df['IsUrban'].value_counts().to_string())
print('------------------------')
print('NUMERO DI PRESTITI REVOLVING (1 = Linea di credito Revolving)')
print(df['RevLineCr'].value_counts().to_string())
print('------------------------')
print('NUMERO DI LOW DOCUMENTATION LOANS (1 = LDL)')
print(df['LowDoc'].value_counts().to_string())
print('------------------------')
PRIMI 5 STATI PER PRESTITI CA 100455 TX 51881 NY 45297 FL 34114 PA 28374 ------------------------ PRIMI 5 SETTORI PER PRESTITI Retail_trade 125689 Other_no_pub 71628 Prof/Science/Tech 66758 Manufacturing 66745 Accom/Food_serv 66739 ------------------------ NUMERO DI NUOVE IMPRESE (1 = Nuova impresa) 0 495678 1 190179 ------------------------ NUMERO DI IMPRESE FRANCHISE (1 = Franchise) 0 651395 1 34462 ------------------------ NUMERO DI IMPRESE CON SEDE IN UN CENTRO URBANO (1 = Centro urbano) 1 444266 0 241591 ------------------------ NUMERO DI PRESTITI REVOLVING (1 = Linea di credito Revolving) 1 419289 0 266568 ------------------------ NUMERO DI LOW DOCUMENTATION LOANS (1 = LDL) 0 617339 1 68518 ------------------------
Attraverso un grafico rappresentiamo la distribuzione dei default tra i 51 stati americani
df_state = df.groupby(['State'], as_index=False)['Default'].mean()
df_state.head()
| State | Default | |
|---|---|---|
| 0 | AK | 0.090463 |
| 1 | AL | 0.210145 |
| 2 | AR | 0.193385 |
| 3 | AZ | 0.240996 |
| 4 | CA | 0.223234 |
fig = go.Figure(data=go.Choropleth(
locations=df_state['State'], # Location
z = df_state['Default'].astype(float), # Dati
locationmode = 'USA-states',
colorscale = 'Reds',
colorbar_title = "Default %",
))
fig.update_layout(
title_text = 'Percentuale di prestiti non rimborsati per singolo Stato',
geo_scope='usa', # Zona mappa
)
fig.show()
Adesso andiamo a rappresentare la media dei default e la percentuale della quota garantita da SBA per ogni tipologia di aziende
col=['Default', 'SBA_Pct']
df_ind = df.groupby(['IndustryType'], as_index=False)[col].mean()
df_ind['Default']=df_ind['Default']*100
df_ind.head()
| IndustryType | Default | SBA_Pct | |
|---|---|---|---|
| 0 | Accom/Food_serv | 22.075548 | 70.907750 |
| 1 | Admin_sup/Waste_Mgmt_Rem | 23.694628 | 62.610433 |
| 2 | Ag/For/Fish/Hunt | 9.045737 | 76.468856 |
| 3 | Arts/Entertain/Rec | 20.653531 | 70.643439 |
| 4 | Construction | 23.350789 | 63.744736 |
fig, ax = plt.subplots()
width =0.4
ax.bar(np.arange(len(df_ind['Default'])), df_ind['Default'], width=width)
ax.bar(np.arange(len(df_ind['SBA_Pct']))+ width, df_ind['SBA_Pct'], width=width, tick_label=df_ind['IndustryType'])
#Sistemiamo il grafico
sns.set_theme()
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_color('#DDDDDD')
ax.tick_params(bottom=False, left=False)
fig.autofmt_xdate()
ax.set_axisbelow(True)
ax.yaxis.grid(True, color='#FFFFFF')
ax.xaxis.grid(False)
ax.legend(['Default', 'Quota SBA'],prop=dict(size=15))
#Titolo
ax.set_title('Media percentuale di Default e quota garantita da SBA, per settore', pad=20, color='#333333',
weight='bold', fontsize=20)
plt.rcParams['figure.figsize'] = (20, 10)
plt.show()
Emerge come i settori più "solidi" siano imprese del settore primario come Agricoltura/Pesca/Caccia o quello minerario, al contrario di servizi come quello Assicurativo che presenta la percentuale di default più alta.
Per quanto riguarda la quota di prestito garantito da SBA, possiamo ritenere che in media la forbice vada da un minimo del 60% a circa il 75%.
Consideriamo ora gli anni della recessione e andiamo a verificarne gli effetti sulla percentuale di default e quota garantita da SBA
df_rec = df.loc[df['Recession'] == 1]
df_rec = df_rec.groupby(['IndustryType'], as_index=False)[col].mean()
df_rec['Default'] = df_rec['Default'] * 100
fig, ax = plt.subplots()
width =0.4
ax.bar(np.arange(len(df_rec['Default'])), df_rec['Default'], width=width)
ax.bar(np.arange(len(df_rec['SBA_Pct']))+ width, df_rec['SBA_Pct'], width=width, tick_label=df_rec['IndustryType'])
#Sistemiamo il grafico
sns.set_theme()
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_color('#DDDDDD')
ax.tick_params(bottom=False, left=False)
fig.autofmt_xdate()
ax.set_axisbelow(True)
ax.yaxis.grid(True, color='#FFFFFF')
ax.xaxis.grid(False)
ax.legend(['Default', 'Quota SBA'],prop=dict(size=15))
#Titolo
ax.set_title('Media percentuale di Default e quota garantita da SBA, per settore, durante la Recessione del 2007 - 2009', pad=20, color='#333333',
weight='bold', fontsize=20)
plt.rcParams['figure.figsize'] = (20, 10)
plt.show()
Contrariamente a quanto potrebbe aspettarsi, la recessione sembra aver avuto un effetto marginale sia sulla quota di prestiti garantita da SBA sia sulla percentuale di prestiti in default, infatti:
print(f"In media, il {df['Default'].mean()*100}% dei prestiti finisce in default")
print(f"Durante la recessione, il {df_rec['Default'].mean()}% dei prestiti è finito in default")
In media, il 20.3091898165361% dei prestiti finisce in default Durante la recessione, il 19.700500983160016% dei prestiti è finito in default
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
from xgboost import XGBClassifier
#One-hot encoding per la feature 'IndustryType'
df = pd.get_dummies(df)
df.head()
| ApprovalFY | Term | NoEmp | IsNew | CreateJob | RetainedJob | IsFranchise | IsUrban | RevLineCr | LowDoc | DisbursementFY | DisbursementGross | Default | GrAppv | RealEstate | Recession | SBA_Pct | State_AK | State_AL | State_AR | State_AZ | State_CA | State_CO | State_CT | State_DC | State_DE | State_FL | State_GA | State_HI | State_IA | State_ID | State_IL | State_IN | State_KS | State_KY | State_LA | State_MA | State_MD | State_ME | State_MI | State_MN | State_MO | State_MS | State_MT | State_NC | State_ND | State_NE | State_NH | State_NJ | State_NM | State_NV | State_NY | State_OH | State_OK | State_OR | State_PA | State_RI | State_SC | State_SD | State_TN | State_TX | State_UT | State_VA | State_VT | State_WA | State_WI | State_WV | State_WY | IndustryType_Accom/Food_serv | IndustryType_Admin_sup/Waste_Mgmt_Rem | IndustryType_Ag/For/Fish/Hunt | IndustryType_Arts/Entertain/Rec | IndustryType_Construction | IndustryType_Educational | IndustryType_Finance/Insurance | IndustryType_Healthcare/Social_assist | IndustryType_Information | IndustryType_Manufacturing | IndustryType_Mgmt_comp | IndustryType_Min/Quar/Oil_Gas_ext | IndustryType_Other_no_pub | IndustryType_Prof/Science/Tech | IndustryType_Public_Admin | IndustryType_RE/Rental/Lease | IndustryType_Retail_trade | IndustryType_Trans/Ware | IndustryType_Utilities | IndustryType_Wholesale_trade | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1997 | 84 | 4 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1999 | 60000.0 | 0 | 60000.0 | 0 | 0 | 80.0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |
| 1 | 1997 | 60 | 2 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1997 | 40000.0 | 0 | 40000.0 | 0 | 0 | 80.0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2 | 1997 | 180 | 7 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1997 | 287000.0 | 0 | 287000.0 | 0 | 1 | 75.0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 5 | 1997 | 120 | 19 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1997 | 517000.0 | 0 | 517000.0 | 0 | 1 | 75.0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 7 | 1997 | 84 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1998 | 45000.0 | 0 | 45000.0 | 0 | 0 | 80.0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
#Stabiliamo l'oggetto della nostra analisi e le features
y = df['Default'] #oggetto
X = df.drop('Default', axis=1) #features
#Standardizziamo le feature prima di iniziare
scale = StandardScaler()
X_std = scale.fit_transform(X)
#Creiamo il campione test
X_train, X_val, y_train, y_val = train_test_split(X_std, y, test_size=0.25)
#Modello logistico
log_reg = LogisticRegression(random_state=1)
#Fase training
log_reg.fit(X_train, y_train)
pred = log_reg.predict(X_val)
#Risultati
print(classification_report(y_val, pred, digits=3))
precision recall f1-score support
0 0.871 0.954 0.910 136458
1 0.714 0.448 0.550 35007
accuracy 0.851 171465
macro avg 0.792 0.701 0.730 171465
weighted avg 0.839 0.851 0.837 171465
Sebbene l'accuracy del modello logistico sia al 85%, l'F1-score non è buono.
Infatti la precision ci suggerisce che l'algoritmo è corretto il 71% delle volte quando un prestito è in default, ma secondo l'indice 'recall' solo il 43% delle volte l'algoritmo riconosce un debito in default correttamente, significa quindi che il 57% di prestiti in default sono stati considerati come saldati. Non è consigliabile usare questo modello.
#XGBoost model
xgboost = XGBClassifier(random_state=2)
xgboost.fit(X_train, y_train)
xgbpred = xgboost.predict(X_val)
# Print the results
print(classification_report(y_val, xgbpred, digits=3))
[18:23:41] WARNING: C:/Users/Administrator/workspace/xgboost-win64_release_1.4.0/src/learner.cc:1095: Starting in XGBoost 1.3.0, the default evaluation metric used with the objective 'binary:logistic' was changed from 'error' to 'logloss'. Explicitly set eval_metric if you'd like to restore the old behavior.
precision recall f1-score support
0 0.963 0.970 0.966 136458
1 0.878 0.854 0.866 35007
accuracy 0.946 171465
macro avg 0.920 0.912 0.916 171465
weighted avg 0.946 0.946 0.946 171465
Il modello XGBoost offre risultati decisamente migliori.
L'accuracy sale a circa il 95% mentre l'F1-score è al 86%. Nello specifico l'algoritmo è corretto quasi l'88% delle volte quando un prestito è in default, inoltre la percentuale di prestiti in default considerati come saldati scende al 15% (era il 57% per il modello logistico).
SMOTE è un algoritmo che permette di bilanciare le classi, proviamolo insieme all'algoritmo XGBoost
from imblearn.over_sampling import SMOTE
smote = SMOTE()
X_train_balanced, y_train_balanced = smote.fit_resample(X_train, y_train)
X_val_balanced, y_val_balanced = smote.fit_resample(X_val, y_val)
#XGBoost con i valori "bilanciati"
xgboost = XGBClassifier(random_state=2)
xgboost.fit(X_train_balanced, y_train_balanced)
xgbpred = xgboost.predict(X_val_balanced)
# Print the results
print(classification_report(y_val_balanced, xgbpred, digits=3))
Processare i nostri dati attraverso l'algoritmo SMOTE si è rivelata una decisione "costosa" in termini di tempi di esecuzione ma a giudicare dal report ha decisamente migliorato la bontà della nostra analisi.
Se l'accuracy resta buona, il nostro f1-score migliora di ben 10 punti percentuali.
La precision passa dal 87% al 96% e l'indice recall passa dal circa 86% al 96%. Questo significa che se prima il 15% dei prestiti default veniva considerato come saldato, la percentuale ora si è abbassaa al 3,5% (eravamo partiti dal 57% del modello logistico!)
A questo punto ci si può dire soddisfatti del lavoro svolto.